PR #16325 — [BugFix][Attention] Build SFA indexer DCP metadata independently(commit 8a36237e9,2026-09-17)
三阶段演进线:业务流程(背景)→ 当前方案 对照 目标方案(方案)→ 目标实现流程(实现)。
背景开始前先做概念爬坡(从普通 attention 出发,五级爬到 SFA / indexer / DCP);之后每阶段按「概览层 → 细节层」组织,逐节配图、一图一问、图文交替。可对仓库 /root/workspace/vllm-ascend 的 8a36237e9 一起读。
本段文字 ≈ 1500 字(要求 ≥600)· 概念爬坡图 6 + 概览 1 + 细节 3 = 图 10 张
普通大模型推理里有两段:prefill 一次性把整段输入并行算完,decode 一个 token 一个 token 往下生成。生成时当前 query 要「回头看」之前所有 token 的键值对——这些键值对被存在 KV cache 里,attention 用它算当前 token 与每个历史 token 的相关性。序列越长,这一步要关联的历史就越多,成本大致是平方增长(O(n²))。
解读:「全部」两个字就是后面所有故事的起点——长上下文时,这个「全部」让计算量暴涨,于是人们想:能不能只看一部分?
因为「每个 query 都要关注全部历史」太贵,于是有了省事的办法:每个 query 只和自己最相关的 k 个历史位置算 attention。这种「只挑 k 个」的注意力,仓库里叫 SFA(稀疏注意力 backend,Sparse Flash Attention)。代价是:怎么挑出「最相关的 k 个」,本身需要一次计算。
解读:从「和全部算」到「和 k 个算」,省的是算力;新增的成本是那个「挑选」。这一步的课题:挑选结果值得缓存吗?——下一级回答。
因为「挑哪 k 个」的结论会被反复使用(同一位置可能被很多后续 token 查看),与其每次重算,不如让一个专门的模块负责挑选、并把决定存下来。这个模块就叫 indexer(索引器),它有自己的独立缓存(在仓库里按一种叫 LI-C8 的紧凑布局存放)。注意:indexer 缓存和主 KV 缓存是两套东西——一个存 key/value,一个存「该看哪几个位置」的索引。
解读:画面上已经出现两套缓存(主 KV 缓存 与 indexer 缓存)——「它们分别怎么存放」就是第 4 级的问题。
当上下文长到单卡放不下,vLLM 会把一个请求沿 token 序列方向切到多个 rank(卡)。这种「按序列切分」的并行,decode 阶段叫 DCP(decode 上下文并行),prefill 阶段叫 PCP(prefill 上下文并行)。切了之后两种缓存处理方式不一样:主 KV 缓存按 rank 各管各的一段(rank-local——每卡只存自己分到的那段历史);indexer 缓存则选择每张卡都放一份完整副本(replicated——因为每卡都要快速查 top-k 结论)。
解读:「主缓存一人一半、indexer 缓存人手一份」并存——只要谁不小心把前者的地址习惯用到后者身上,就会出错。这就是下一级。
构建 indexer 元数据(比如「这次要写哪个缓存槽位」)时,如果用的是主缓存的 rank-local 地址——「槽位 7 在第 0 块缓存里」——而实际上 indexer 缓存是 replicated 的:每个 rank 各有自己的槽位 7。地址被用错语义,就会写到错的位置:不报错,而是悄悄把索引写乱,推理结果出错。这正是 PR #16325 要修的东西。
解读:修复思路预告(阶段二/三展开):让 indexer 用自己的「复制布局语义」来构建地址——而不是借用主缓存的 rank-local 地址。
| 术语 EN | 大白话 | 代码位置 |
|---|---|---|
| SFA Sparse Flash Attention | 稀疏注意力 backend:每个 query 只看 top-k 个历史位置 | vllm_ascend/attention/sfa_v1.py |
| indexer indexer / indexer cache | 负责挑 top-k 的模块,有自己的独立缓存(LI-C8 布局) | vllm_ascend/attention/indexer.py |
| DCP / PCP Decode/Prefill Context Parallel | 把一个长请求沿序列切到多张卡的并行方式 | vllm_ascend/attention/context_parallel/ |
| rank-local / replicated | 主缓存每卡各存一段 vs indexer 缓存每卡一份完整副本 | —(布局概念) |
| CommonAttentionMetadata | 上游 vLLM 提供的公共注意力元数据,插件在其上扩展 | 上游 vllm 包 |
| LI-C8 | indexer 缓存的一种紧凑存放布局代号 | indexer.py / sfa_v1.py |
| MTP Multi-Token Prediction | 投机解码方式;草稿层可能与主层共享物理缓存(KV-sharing) | spec_decode/ |
用法:这张表不是第一遍读的地方;读后面章节遇到不懂的名词,回来查。
GLM-5.x 的稀疏注意力(SFA)在 Ascend 上多了一个 indexer(索引器):每个 query 只关注 top-k 个历史位置,indexer 决定关注谁,它有自己独立的一份缓存。问题出在并行布局上——SFA 主缓存是 rank-local(每人只管自己分到的 token),而 indexer 缓存在 DCP rank 之间复制(每人一份完整副本)。构建 indexer 元数据时若沿用 SFA 的 rank-local 地址,就会把 top-k 索引写到错误的复制副本里,长输入精确检索直接错乱。
读图:A→B 是功能主路径;C 是两种布局并存的客观事实;D 是当前实现的做法;E、F 是后果。后面所有细节图都在解释 C、D、E 三个环节。
vLLM 主仓库承担调度与模型执行,NPU 专属逻辑下沉到 vllm-ascend 插件。注意力 backend 在 vllm_ascend/attention/ 下按 backend 组织:SFA 与 indexer 是两个独立 AttentionBackend,context_parallel 提供并行语境的工具。
读图:本 PR 同时动了 C 子层下的 E(indexer backend)与 D(SFA backend),以及 F(context_parallel 新增工具)——插件内部 attention 家族的三处协同改动。
提交 44e316024(#15669,Refactor)把 indexer 从 SFA 内嵌状态解耦成独立 backend,职责更清晰,但也留下隐患:indexer 元数据仍然是「借用」SFA 的。
读图:红线点的本质是「职责分开、数据没分开」——模块独立了,但 indexer 吃的数据仍由 SFA 提供,布局语义被偷换。
现有链路里,indexer builder(旧实现)只做部分字段,其余全部指望 SFA:MTP draft 时甚至 fallback 到整个 SFA 层元数据;前向阶段还往 indexer 元数据里 patch 序列长度与 cos/sin。这张图与阶段三的「目标实现」形成新旧对照。
读图:问题不是单一函数,而是「数据来源」错了:块表和 slot 用 rank-local 语义,前端还要不断给 indexer 元数据打补丁。修复的正解 = 让 indexer 自己从公共事实推导。
本段文字 ≈ 950 字(要求 ≥800)· 图 7 张
老方案依靠「借用 + patch」,新方案依靠「公共事实 + 独立推导」。两张概览图都是设计视角,回答同一个问题:流程从哪变到哪。
解读:老链路的「数据源」是 SFA 的 rank-local 视图,indexer 只是被动接收 + 打补丁——布局一旦变成 replicated 就崩。
解读:新链路的「数据源」换成公共事实,indexer builder 内部自己做 DCP 地址变换、PCP 排序、RoPE 与分组,最后交出完整独立的元数据。
一句话:indexer 不再依赖 SFA,只依赖「公共事实」。公共事实 = 上游 CommonAttentionMetadata + 显式传入的 CP 上下文。
解读:这条依赖线被剪断后,indexer 元数据的「布局语义」才真正属于自己——replicated 就是 replicated,不用猜。
文件 ↔ 角色 ↔ 改动内容,一张图看清分工。
解读:A 产数据、B 管地址换算、C 管公共并行、D 收敛消费端、E 保证 MTP 草稿阶段每个 cache-only 组都能自己建元数据。
方案不是加代码,而是「该加的加、该删的删、该留的留」三件事。
解读:「引入」补全独立推导能力,「移除」清掉数据来源错误,「不变」保证老调用方无感、不引入新算法。
入口保持 AttentionMetadataBuilder 协议不变,内部完全替换。
解读:可变派生 buffer 按「公共 slot-mapping buffer 的身份」做 key(_metadata_buffer_key),图捕获/回放/MTP 各步骤用对存储、不重复分配。
为控制改动面,四条边界划得很清楚——「不做」和「做」一样重要。
解读:B 保证「修 bug 不改算法」;C 说明还是「一个主注意力组」路径;D 是宁可报错不猜;E 是验证策略声明——1500 组 DSA-CP 前后对比单测承担地址换算的正确性。
本段文字 ≈ 1100 字(要求 ≥1000)· 图 7 张 · 代码块 8 个
这是最终代码的调用链主干,与背景图 1-4(当前实现的错误链路)逐段对照:「借用 SFA + patch」被替换成「indexer builder 自产地址」。
读图:R 是调用方,B 是主战场,U/C 是两个无状态工具文件,S 是消费端。左下角四步是本 PR 新增;右下角 S 的一步是 sfa_v1 收敛。以下逐节点展开。
build() 是 AttentionMetadataBuilder 协议的标准入口,外部调用完全无感;它把「同一个公共 slot-mapping 的身份」变成 buffer_key,保证图捕获/回放/MTP 各 step 用对存储。
def build(
self,
common_prefix_len: int,
common_attn_metadata: CommonAttentionMetadata,
fast_build: bool = False,
**kwargs,
) -> AscendSFAIndexerMetadata:
# common_prefix_len / fast_build 保留仅为 API 兼容
return self._build(
common_attn_metadata,
buffer_key=self._metadata_buffer_key(common_attn_metadata),
use_cached_rope=True,
# 投机解码会提前构建多份 step 元数据,每份都要独立 RoPE
copy_rope=self.speculative_config is not None,
**kwargs,
)
buffer_key 与 copy_rope 是「复用存储、不重复分配」的两把钥匙。它把「地址」拆成两步:先按 PCP 与否分流,再产出 (block_table, slot_mapping) 这对最终地址张量。PCP 分支与常规分支最后汇合。
def _build_dcp_cache_metadata(
self, common_attn_metadata, pcp_context, pcp_cache_group_idx, buffer_key,
) -> tuple[torch.Tensor, torch.Tensor]:
pcp_slot_mapping = None
# PCP 开启 且 正在 prefill → PCP 顺序路径
if self.use_pcp and pcp_context is not None and bool(pcp_context.global_batch.is_prefilling_np.any()):
if pcp_cache_group_idx is None:
raise RuntimeError("PCP+DCP indexer metadata requires the PCP cache-group index.")
pcp_slot_mapping = self._build_pcp_ordered_slot_mapping(
common_attn_metadata, pcp_context, pcp_cache_group_idx, buffer_key)
# 常规路径:local 块表 → replicated 块表 → replicated slot
num_reqs = common_attn_metadata.num_reqs
dcp_block_table = get_sfa_dcp_local_block_table(
common_attn_metadata.block_table_tensor, num_reqs, self.max_local_block_table_cols)
block_table = self._build_block_table_replicated_view(
dcp_block_table, common_attn_metadata.seq_lens, buffer_key)
slot_mapping = self._build_slot_mapping_replicated_view(
common_attn_metadata, block_table, buffer_key)
if pcp_slot_mapping is not None:
slot_mapping = pcp_slot_mapping
return block_table, slot_mapping
这是「地址错配」的直接解药:把 rank-local 的物理块表展开成每个 rank 一份的复制视图。换算公式:local_physical × dcp_size + rank_in_replicated_view。
def build_sfa_dcp_replicated_block_table(
dcp_block_table, seq_lens, block_table_output,
replicated_col_idx, dcp_size, blocks_per_physical_block,
):
"""Expand a local SFA block table into replicated-indexer addresses."""
local_col_idx = (
replicated_col_idx // (dcp_size * blocks_per_physical_block)
* blocks_per_physical_block
+ replicated_col_idx % blocks_per_physical_block
)
rank_in_replicated_view = (replicated_col_idx // blocks_per_physical_block) % dcp_size
local_logical_blocks = torch.index_select(dcp_block_table, 1, local_col_idx)
if blocks_per_physical_block == 1:
# [关键] 核心换算:本地物理块 → replicated 块号
replicated_blocks = local_logical_blocks * dcp_size + rank_in_replicated_view
else:
local_sub_blocks = local_logical_blocks % blocks_per_physical_block
local_physical_blocks = local_logical_blocks // blocks_per_physical_block
replicated_blocks = (
local_physical_blocks * dcp_size + rank_in_replicated_view
) * blocks_per_physical_block + local_sub_blocks
valid_req_mask = ((seq_lens[: dcp_block_table.shape[0]].to(device=dcp_block_table.device) > 0)
.to(replicated_blocks.dtype).view(-1, 1))
block_table_output.copy_(replicated_blocks * valid_req_mask)
return block_table_output
有了 replicated 块表,再把每个 token 的 positions 翻译成「写到哪一块的哪个偏移」;没有任何位置映射的 token 填 -1,不会被写。
def build_sfa_dcp_replicated_slot_mapping(
common_attn_metadata, block_table_replicated_view,
slot_mapping_output, replicated_view_block_size, device,
):
"""Build slots that address the physically replicated indexer cache."""
num_reqs = common_attn_metadata.num_reqs
num_actual_tokens = min(common_attn_metadata.num_actual_tokens, common_attn_metadata.num_input_tokens)
num_query_tokens = int(common_attn_metadata.query_start_loc_cpu[num_reqs])
slot_mapping_output.fill_(-1) # 默认占位:不可写
if num_actual_tokens == 0:
return slot_mapping_output
query_lens = (common_attn_metadata.query_start_loc[1 : num_reqs + 1]
- common_attn_metadata.query_start_loc[:num_reqs])
req_indices = torch.repeat_interleave(
torch.arange(num_reqs, dtype=torch.int32, device=device),
query_lens.to(device=device), output_size=num_query_tokens,
)[:num_actual_tokens]
...
logical_block_idx = positions // replicated_view_block_size
block_offsets = positions % replicated_view_block_size
block_table_indices = req_indices * block_table_replicated_view.shape[1] + logical_block_idx
block_numbers = block_table_replicated_view.flatten()[block_table_indices]
# [关键] slot = 块号 × 块大小 + 块内偏移
slot_mapping_output[:num_actual_tokens] = block_numbers * replicated_view_block_size + block_offsets
return slot_mapping_output
fill_(-1) 是「不可写占位」约定。PCP+DCP prefill 要求 slot 按 gather 顺序排列;共享 helper build_pcp_ordered_slot_mapping(common_cp)用 index_select 重排、用 write_mask 掩掉不该写的 token。
def build_pcp_ordered_slot_mapping(
global_slot_mapping: torch.Tensor,
pcp_context: Any,
slot_mapping_buffer: torch.Tensor,
) -> torch.Tensor:
"""Write slots in PCP gather order into a caller-owned output buffer."""
gather_idx = pcp_context.padded_gather_idx
write_mask = pcp_context.gathered_kv_write_mask
if gather_idx is None or write_mask is None:
raise RuntimeError("PCP+DCP prefill requires the PCP gathered-token layout.")
num_tokens = gather_idx.numel()
if slot_mapping_buffer.shape[0] < num_tokens:
raise RuntimeError(
f"PCP+DCP indexer slot buffer is too small: "
f"capacity={slot_mapping_buffer.shape[0]}, required={num_tokens}.")
slot_mapping = slot_mapping_buffer[:num_tokens]
# [关键] 按 gather 顺序 index_select,再掩掉不写的 token
torch.index_select(global_slot_mapping, 0, gather_idx, out=slot_mapping)
slot_mapping.masked_fill_(~write_mask, -1)
return slot_mapping
_build_pcp_ordered_slot_mapping(indexer.py L677)先组全局视图再调它。最后一步在 SFA 前向:取 indexer 元数据时只认 indexer 自己的前缀(共享目标优先),删掉旧的「fallback 到 SFA 层元数据」——SFA 视图不是 replicated 缓存的有效替身,查不到就 raise。
def _get_indexer_attn_metadata(self) -> Any | None:
"""只取 indexer 缓存层自己的元数据(由 indexer backend 构建)。"""
if not self.runtime_has_indexer:
return None
own_prefix = self.indexer.k_cache.prefix
prefixes = [own_prefix]
kv_sharing_target = getattr(self, "kv_sharing_target_layer_name", None)
if kv_sharing_target is not None:
# 优先共享目标的 cache 视图,但保留草稿 indexer 自己的元数据
target_base = kv_sharing_target.removesuffix(".attn")
prefixes = [f"{target_base}.indexer.k_cache", own_prefix]
forward_metadata = get_forward_context().attn_metadata
indexer_metadata = None
if isinstance(forward_metadata, dict):
for prefix in prefixes:
indexer_metadata = forward_metadata.get(prefix)
if indexer_metadata is not None:
break
if indexer_metadata is None:
raise RuntimeError(
f"No metadata was built for the indexer cache layer "
f"prefixes={prefixes}. layer_name={self.layer_name}.")
return indexer_metadata
seq_lens / num_decode_tokens、不再传 cos/sin(indexer 从自己 metadata 读)。解读:修复前同拓扑图模式 20 例仅 2 例过、0/9 精确——即「静默错 token」的实证;修复后 20/20 + 39,441 token 逐字一致。测试证据与背景图 1-4 的「错误链路」形成闭环。
| 阶段 | 层次 | 小节标题 | 文字字数 | 图数量 | 图编号+视角 | 单图最大节点数 | 代码块数 | 是否达标 |
|---|---|---|---|---|---|---|---|---|
| 背景 | 概念爬坡 | 第1级~第5级 + 术语索引 | ≈1500 | 6 | 图0-1~0-6 · 常识→概念(含 术语索引图 0-6) | 5 | — | ✅ |
| 背景 | 概览 | 一句话处境 + 业务流程 | 计入上格 | 1 | 图1 · 问题视角 | 6 | — | ✅ |
| 背景 | 细节 | 1.1 定位 / 1.2 前因 / 1.3 痛点 | 计入上格 | 3 | 图1-1~1-3 · 问题+结构视角 | 7 | — | ✅ |
| 方案 | 概览 | 当前 vs 目标方案流程 | 计入下格 | 2 | 图2-1/2-2 · 设计视角 | 7 | — | ✅ |
| 方案 | 细节 | 核心 / 模块 / 增删 / 接老流程 / 边界 | ≈950 | 5 | 图2-3~2-7 · 设计视角 | 5 | — | ✅ |
| 实现 | 概览 | 调用链时序主干 | 计入下格 | 1 | 图3 · 执行视角 | 5 参与者 | — | ✅ |
| 实现 | 细节 | 节点①–⑥ + 验证 | ≈1100 | 7 | 图3-1~3-7 · 执行视角 | 5 | 8 | ✅ |
| 阶段 | h3 小节标题 | 是否配图 | 图编号 | 备注 |
|---|---|---|---|---|
| 背景 | 爬坡第 1 级 · 普通 attention + KV cache | ✅ | 图 0-1 | 常识锚定,确认在同一页 |
| 背景 | 爬坡第 2 级 · 稀疏注意力 SFA(只看 top-k) | ✅ | 图 0-2 | 从「全量」推出「只看 k 个」 |
| 背景 | 爬坡第 3 级 · indexer 与独立缓存 | ✅ | 图 0-3 | 从「挑选可复用」推出独立模块 |
| 背景 | 爬坡第 4 级 · DCP/PCP + 两种缓存布局 | ✅ | 图 0-4 | 从「单卡放不下」推出切多卡 |
| 背景 | 爬坡第 5 级 · 地址错配痛点 | ✅ | 图 0-5 | 从「两种布局并存」推出 bug |
| 背景 | 回头查阅 · 术语速查表 | ✅ | 图 0-6 | 降级为索引,不作首次引入 |
| 背景 | 1.1 定位 | ✅ | 图 1-1 | 层级关系图 |
| 背景 | 1.2 前因 | ✅ | 图 1-2 | 拆分前后对比 + 隐患 |
| 背景 | 1.3 痛点 | ✅ | 图 1-3 | 当前实现错误链路,与图 3 对照 |
| 方案 | 2.1 设计核心 | ✅ | 图 2-3 | 依赖方向反转 |
| 方案 | 2.2 模块划分 | ✅ | 图 2-4 | 文件↔角色 |
| 方案 | 2.3 新引入/移除/变化 | ✅ | 图 2-5 | 增删对比 |
| 方案 | 2.4 与老流程怎么接 | ✅ | 图 2-6 | 入口不变、内部替换 |
| 方案 | 2.5 方案边界 | ✅ | 图 2-7 | 四条不做的事 |
| 实现 | 3.1 节点① build() | ✅ | 图 3-1 + CB① | ≤5 节点 + 代码 |
| 实现 | 3.2 节点② 装配心脏 | ✅ | 图 3-2 + CB② | PCP/常规双路径 |
| 实现 | 3.3 节点③ 地址变换 | ✅ | 图 3-3 + CB③ | local→replicated 公式 |
| 实现 | 3.4 节点④ slot 生成 | ✅ | 图 3-4 + CB④ | 位置→slot |
| 实现 | 3.5 节点⑤ PCP 顺序 | ✅ | 图 3-5 + CB⑤ | gather 重排 |
| 实现 | 3.6 节点⑥ 消费端收敛 | ✅ | 图 3-6 + CB⑥ | fallback 删除 |
| 实现 | 3.7 验证 | ✅ | 图 3-7 | 证据分层 |
| 级别 | 引入的概念 | 上一级是什么 | 怎么从上一级推出 | 图编号 | 是否空降 |
|---|---|---|---|---|---|
| 1 | 普通 attention + KV cache | 读者已知的普通 LLM 推理 | 锚定常识、确认同一页;prefill/decode / KV cache 只作回顾 | 图 0-1 | 否 |
| 2 | SFA 稀疏注意力(只看 top-k) | 普通 attention 是 O(n²) | 因为「全量太贵」→ 「只看 k 个」 | 图 0-2 | 否 |
| 3 | indexer 与 indexer cache(LI-C8) | 「挑 top-k」需要计算 | 因为「挑选结论可复用」→ 独立模块 + 独立缓存 | 图 0-3 | 否 |
| 4 | DCP / PCP + rank-local / replicated | 长上下文单卡放不下 | 因为「要切多卡」→ 主缓存各存一段、indexer 缓存每卡一份 | 图 0-4 | 否 |
| 5 | 地址错配 bug | 两种缓存布局并存 | 因为「布局不同却沿用 rank-local 地址」→ 写错位置、静默出错 | 图 0-5 | 否 |
| 索引 | 术语速查表 | 爬坡全部完成 | 仅作回头查阅,不作首次引入 | 图 0-6 | 否 |
硬约束复核:a) 字数 bg≈800 / plan≈950 / impl≈1100 均达下限 ✅ b) 每图标注「这张图回答什么问题」✅ c) 视角不混用(背景=问题/结构 · 方案=设计 · 实现=执行)✅ d) 概览 ≤7、细节 ≤5 节点 ✅ e) 概览层→细节层分层 + 图文交替 ✅ f) 每个 h3 均有配图,无纯文字小节 ✅ g) 概念爬坡 5 级逐级推出、无术语空降;术语表降级为索引 ✅ h) 全站浅色主题(#f7f8fa / #ffffff / atom-one-light / mermaid 浅底)✅